Event Sourcing's Dirty Secret: Nobody Talks About the Replay Bill
Event sourcing conference talks sell you the audit trail and the time travel. They rarely mention what it costs to replay millions of events years later, or what happens the day you need to change what an old event means.
The Pitch, Which Is True
Store every state change as an immutable event instead of overwriting a row, and you get a complete history of everything that ever happened to an aggregate, for free. Need to know what the order looked like at any point in its life? Replay the events up to that point. Need a new read model you didn't think of a year ago? Replay the whole stream through the new projection. It's a genuinely good idea, and the pitch is accurate. What the pitch leaves out is the word "replay" doing an enormous amount of quiet work.
The Bill Arrives in Three Parts
Replay time grows with history, not with current state. A traditional CRUD table's read
cost is roughly constant regardless of how long the row has existed — an UPDATE from three
years ago left no trace. An event-sourced aggregate's rebuild cost grows with every event it has ever
received. An account that's been live for five years and processed ten thousand transactions takes
meaningfully longer to rebuild than one from last week, even if their current balance is identical. This is
usually fine, until it isn't — a projection rebuild that takes 40 minutes for your oldest aggregates during
an incident is 40 minutes your team is watching a progress bar instead of fixing the actual problem.
Snapshotting is not optional past a certain scale, and it adds a whole second failure surface. The standard fix for replay time is to periodically snapshot current state so you only replay events since the last snapshot. That's the right call — but now you have two sources of truth that need to agree: the event stream (canonical) and the snapshot (a cache of a projection of that stream). Snapshot logic itself can have bugs. A snapshot taken with an off-by-one version number, or taken mid-transaction, silently corrupts every read that trusts it until someone notices the numbers don't add up.
Schema evolution on immutable data is a genuinely hard problem, not a footnote. Events are supposed to be immutable — that's the whole point, it's your audit trail. But the code that interprets those events changes constantly. Rename a field, add a required one, change what a status code means, and you now have events on disk in the old shape and code expecting the new shape. Upcasting layers that translate old event versions into new ones at read time are the standard answer, and they work — but every one of them is permanent. You cannot delete the V1-to-V2 upcaster once you've added a V3, because somewhere in your history there's still a V1 event that needs the full chain to become readable today.
What This Looks Like Concretely
function rebuild(events) {
let account = Account.empty();
for (const event of events) {
// Every event ever emitted for this aggregate, every single time.
account = account.apply(upcast(event));
}
return account;
}
That upcast(event) call is where years of schema decisions live. Three schema versions in, it's
a small if/else. Ten versions in, across a team that's turned over twice, it's a genuine archaeological
record — and every event still has to pass through all of it, forever, because you promised immutability
and immutability doesn't come with an expiration date on the complexity it defers.
None of This Means Don't Use It
Event sourcing is the right tool when the audit trail and the ability to derive new projections from history are actual requirements — financial ledgers, compliance-heavy domains, anything where "what happened and in what order" is itself the product, not a side effect of storing state. In those domains, the replay bill is a cost you were always going to pay somewhere; event sourcing just makes it visible and structured instead of hidden in a change-log table nobody trusts.
Where it goes wrong is reaching for it because it sounds architecturally impressive for a CRUD-shaped problem that never needed history as a first-class citizen. If nobody has ever asked "what did this record look like on a specific past date" and nobody ever will, you're pre-paying a replay bill, a snapshot maintenance bill, and a schema evolution bill for a capability that was never on the requirements list. Budget for the bill before you buy the pattern, not after the first incident that makes you go looking for it.
FAQ
At what scale does replay time actually become a problem?
There's no universal number — it depends on event volume per aggregate and how often you rebuild state from scratch versus reading from a snapshot or cache. It becomes a problem the moment rebuild time affects a latency-sensitive path, such as loading an aggregate on every write.
Can I just delete old events I don't need anymore?
Only if you're certain nothing — including snapshots, audit requirements, and any future projection — depends on them. Deleting events undermines the core guarantee of event sourcing, so most teams archive to cold storage instead of deleting outright.
How do upcasters avoid becoming an unmaintainable mess over time?
Keep each version-to-version upcaster small, pure, and independently tested, and chain them rather than writing one function that tries to handle every historical version at once. Treat the chain itself as a piece of infrastructure with its own test suite, not an afterthought bolted onto the read path.
Is CQRS required if I use event sourcing?
No, but the two pair naturally: event sourcing gives you a canonical write-side log, and CQRS gives you purpose-built read models derived from it, which is usually how you avoid replaying the full event stream on every read.

